| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943944945946947948949950951952953954955956957958959960961962963964965966967968969970971972973974975976977978979980981982983984985986987988989990991992993994995996997998999100010011002100310041005100610071008100910101011101210131014101510161017101810191020102110221023102410251026102710281029103010311032103310341035103610371038103910401041104210431044104510461047104810491050105110521053105410551056105710581059106010611062106310641065106610671068106910701071107210731074107510761077107810791080108110821083108410851086108710881089109010911092109310941095109610971098109911001101110211031104110511061107110811091110111111121113111411151116111711181119112011211122112311241125112611271128112911301131113211331134113511361137113811391140114111421143114411451146114711481149115011511152115311541155115611571158115911601161116211631164116511661167116811691170117111721173117411751176117711781179118011811182118311841185118611871188118911901191119211931194119511961197119811991200120112021203120412051206120712081209121012111212121312141215121612171218121912201221122212231224122512261227122812291230123112321233123412351236123712381239124012411242124312441245124612471248124912501251125212531254125512561257125812591260126112621263126412651266126712681269127012711272127312741275127612771278127912801281128212831284128512861287128812891290129112921293129412951296129712981299130013011302130313041305130613071308130913101311131213131314131513161317131813191320132113221323132413251326132713281329133013311332133313341335133613371338133913401341134213431344134513461347134813491350135113521353135413551356135713581359136013611362136313641365136613671368136913701371137213731374137513761377137813791380138113821383138413851386138713881389139013911392139313941395139613971398139914001401140214031404140514061407140814091410141114121413141414151416141714181419 |
- 'use client';
- import { useState, useEffect, useCallback, useRef } from 'react';
- import { useParams, useRouter } from 'next/navigation';
- import { useAuth } from '@/lib/auth-context';
- import { projectsApi, assetsApi, invitationsApi, foldersApi, Project, Asset, Invitation, TranscodeStatus, FolderNode } from '@/lib/api';
- import { Avatar } from '@/components/ui/avatar';
- import { AssetCard } from '@/components/ui/AssetCard';
- import { FolderTree } from '@/components/folders/FolderTree';
- import { ShareModal } from '@/components/share/ShareModal';
- import { TranscodeTasksPanel } from '@/components/transcode/TranscodeTasksPanel';
- import { useDropzone } from 'react-dropzone';
- import { useUploadQueue } from '@/contexts/UploadQueueContext';
- async function safeCopy(text: string): Promise<void> {
- if (typeof window === 'undefined') return;
- try {
- const cb = navigator.clipboard;
- if (cb && typeof cb.writeText === 'function') {
- await cb.writeText(text);
- } else {
- const el = document.createElement('textarea');
- el.value = text;
- el.style.cssText = 'position:fixed;top:-999px;left:-999px;opacity:0';
- document.body.appendChild(el);
- el.focus(); el.select();
- try { document.execCommand('copy'); } catch { /* ignore */ }
- document.body.removeChild(el);
- }
- } catch { /* ignore */ }
- }
- const ROLE_COLORS: Record<string, string> = {
- ADMIN: 'badge-danger',
- EDITOR: 'badge-brand',
- REVIEWER:'badge-muted',
- VIEWER: 'badge-subtle',
- };
- const ROLE_LABELS: Record<string, string> = {
- ADMIN: 'Admin',
- EDITOR: 'Editor',
- REVIEWER:'Reviewer',
- VIEWER: 'Viewer',
- };
- function formatGroupDate(d: Date): string {
- const now = new Date();
- const today = new Date(now.getFullYear(), now.getMonth(), now.getDate());
- const yesterday = new Date(today.getTime() - 86400000);
- const videoDay = new Date(d.getFullYear(), d.getMonth(), d.getDate());
- if (videoDay.getTime() === today.getTime()) return 'Today';
- if (videoDay.getTime() === yesterday.getTime()) return 'Yesterday';
- return d.toLocaleDateString('en-US', { weekday: 'long', month: 'long', day: 'numeric' });
- }
- function groupByDay(assets: Asset[]): [string, Asset[]][] {
- const sorted = [...assets].sort(
- (a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime()
- );
- const groups: Record<string, Asset[]> = {};
- for (const a of sorted) {
- const d = new Date(a.createdAt);
- const day = new Date(d.getFullYear(), d.getMonth(), d.getDate()).toISOString();
- if (!groups[day]) groups[day] = [];
- groups[day].push(a);
- }
- return Object.entries(groups);
- }
- /** Collect asset IDs DIRECTLY in a folder (not from subfolders) */
- function collectAssetIds(folders: FolderNode[], targetId: string | null): Set<string> {
- const ids = new Set<string>();
- if (targetId === null) return ids; // "All Videos" — no filter
- function findTarget(f: FolderNode): FolderNode | null {
- if (f.id === targetId) return f;
- for (const c of f.children) { const r = findTarget(c); if (r) return r; }
- return null;
- }
- for (const f of folders) {
- const target = findTarget(f);
- if (target) { for (const id of target.assetIds) ids.add(id); break; }
- }
- return ids;
- }
- /** Get direct subfolders of a folder */
- function getSubfolders(folders: FolderNode[], targetId: string | null): FolderNode[] {
- if (targetId === null) return folders; // root: show top-level folders
- function findTarget(f: FolderNode): FolderNode | null {
- if (f.id === targetId) return f;
- for (const c of f.children) { const r = findTarget(c); if (r) return r; }
- return null;
- }
- for (const f of folders) {
- const target = findTarget(f);
- if (target) return [...target.children].sort((a, b) => a.order - b.order || a.name.localeCompare(b.name));
- }
- return [];
- }
- /** Build a map of assetId -> single deepest folder name */
- function buildAssetFolders(allFolders: FolderNode[]): Map<string, string> {
- const map = new Map<string, string>();
- const depthMap = new Map<string, number>();
- function search(f: FolderNode, depth: number): void {
- for (const id of f.assetIds) {
- const existingDepth = depthMap.get(id) ?? -1;
- if (depth > existingDepth) {
- map.set(id, f.name);
- depthMap.set(id, depth);
- }
- }
- for (const child of f.children) search(child, depth + 1);
- }
- for (const f of allFolders) search(f, 0);
- return map;
- }
- /** Get the folder name an asset belongs to (deepest only) */
- function getAssetFolderNames(assetFolders: Map<string, string>, assetId: string): string[] {
- const name = assetFolders.get(assetId);
- return name ? [name] : [];
- }
- /** Returns a breadcrumb path of folder names for the selected folder */
- function getBreadcrumb(folders: FolderNode[], targetId: string | null): string[] {
- if (targetId === null) return [];
- const path: string[] = [];
- function search(f: FolderNode, trail: string[]): boolean {
- if (f.id === targetId) { path.push(...trail, f.name); return true; }
- for (const child of f.children) {
- if (search(child, [...trail, f.name])) return true;
- }
- return false;
- }
- for (const f of folders) if (search(f, [])) break;
- return path;
- }
- export default function ProjectDetailPage() {
- const params = useParams();
- const projectId = params.projectId as string;
- const { user, token } = useAuth();
- const router = useRouter();
- const [project, setProject] = useState<Project | null>(null);
- const [members, setMembers] = useState<any[]>([]);
- const [pendingInvites, setPendingInvites] = useState<Invitation[]>([]);
- const [assets, setAssets] = useState<Asset[]>([]);
- const [folders, setFolders] = useState<FolderNode[]>([]);
- const [allFolders, setAllFolders] = useState<FolderNode[]>([]);
- const [selectedFolderId, setSelectedFolderId] = useState<string | null>(null);
- const [viewMode, setViewMode] = useState<'file' | 'timeline'>('file');
- const [loading, setLoading] = useState(true);
- const [sharingAssetId, setSharingAssetId] = useState<string | null>(null);
- const [activeTab, setActiveTab] = useState<'videos' | 'members' | 'transcode'>('videos');
- // Invite form state (single shared form)
- const [inviteEmail, setInviteEmail] = useState('');
- const [inviteRole, setInviteRole] = useState('REVIEWER');
- const [inviting, setInviting] = useState(false);
- const [inviteError, setInviteError] = useState('');
- const [inviteSuccess, setInviteSuccess] = useState('');
- const [createdLink, setCreatedLink] = useState('');
- const [createdLinkEmail, setCreatedLinkEmail] = useState('');
- const [linkCopiedAgain, setLinkCopiedAgain] = useState(false);
- // Edit member role
- const [editingRoleId, setEditingRoleId] = useState<string | null>(null);
- const [editingRole, setEditingRole] = useState('');
- const [updatingRole, setUpdatingRole] = useState(false);
- // Remove member
- const [confirmRemove, setConfirmRemove] = useState<{ id: string; name: string } | null>(null);
- const [removing, setRemoving] = useState(false);
- // Revoke invite
- const [revokingId, setRevokingId] = useState<string | null>(null);
- // Copy link
- const [copiedInviteId, setCopiedInviteId] = useState<string | null>(null);
- const [inviteUrlMap, setInviteUrlMap] = useState<Record<string, string>>({});
- const [reprocessingAll, setReprocessingAll] = useState(false);
- const [globalStuckCount, setGlobalStuckCount] = useState(0);
- const canManage = members.some(m =>
- m.user.id === user?.id && ['ADMIN', 'EDITOR'].includes(m.role)
- );
- const isOwner = project?.ownerId === user?.id;
- const isAdmin = user?.globalRole === 'ADMIN';
- // Poll workspace-wide stuck job count every 30s (admins + editors can use it)
- useEffect(() => {
- if ((!isAdmin && !canManage) || !token) return;
- let cancelled = false;
- async function fetchStuckCount() {
- const t = token as string;
- try {
- const data = await assetsApi.getStuckCount(t, projectId as string);
- if (!cancelled) setGlobalStuckCount(data.count ?? 0);
- } catch {}
- }
- fetchStuckCount();
- const id = setInterval(fetchStuckCount, 30_000);
- return () => { cancelled = true; clearInterval(id); };
- }, [isAdmin, canManage, token]);
- // ── Folder data derived from state ──────────────────────────────────────────
- // For file mode: only assets directly in the selected folder
- const folderAssetIds = assets.length > 0
- ? collectAssetIds(folders, selectedFolderId)
- : new Set<string>();
- // For timeline mode: assets in selected folder AND all its subfolders
- const timelineAssetIds = (() => {
- const ids = new Set<string>();
- if (selectedFolderId === null) return ids;
- function findTarget(f: FolderNode): FolderNode | null {
- if (f.id === selectedFolderId) return f;
- for (const c of f.children) { const r = findTarget(c); if (r) return r; }
- return null;
- }
- function collectAll(f: FolderNode): void {
- for (const id of f.assetIds) ids.add(id);
- for (const c of f.children) collectAll(c);
- }
- for (const f of folders) {
- const target = findTarget(f);
- if (target) { collectAll(target); break; }
- }
- return ids;
- })();
- const filteredAssets = selectedFolderId === null
- ? assets
- : (folderAssetIds.size > 0 ? assets.filter(a => folderAssetIds.has(a.id)) : []);
- // Timeline uses all assets in the selected folder AND its subfolders
- const timelineAssets = selectedFolderId === null
- ? assets
- : (timelineAssetIds.size > 0 ? assets.filter(a => timelineAssetIds.has(a.id)) : []);
- const subfolders = getSubfolders(folders, selectedFolderId);
- const breadcrumb = getBreadcrumb(folders, selectedFolderId);
- const assetFolders = buildAssetFolders(allFolders);
- // ── Delete project ──────────────────────────────────────────────────────────
- const [confirmDeleteProject, setConfirmDeleteProject] = useState(false);
- const [deletingProject, setDeletingProject] = useState(false);
- const handleDeleteProject = async () => {
- if (!token) return;
- setDeletingProject(true);
- try {
- await projectsApi.delete(token, projectId);
- router.push('/projects');
- } catch (err) {
- alert(err instanceof Error ? err.message : 'Failed to delete project');
- } finally {
- setDeletingProject(false);
- setConfirmDeleteProject(false);
- }
- };
- const loadFolders = useCallback(async () => {
- if (!token) return;
- try {
- const data = await foldersApi.list(token, projectId);
- setFolders(data.folders);
- setAllFolders(data.allFolders);
- } catch (e) {
- console.error('Failed to load folders:', e);
- }
- }, [token, projectId]);
- const loadAll = useCallback(async () => {
- if (!token) return;
- try {
- const [{ project: p }, { assets: a }] = await Promise.all([
- projectsApi.get(token, projectId),
- assetsApi.list(token, projectId),
- ]);
- setProject(p);
- setMembers(p.members ?? []);
- setAssets(a);
- if (canManage) {
- const { invitations } = await invitationsApi.list(token, projectId);
- setPendingInvites(invitations.filter((i: Invitation) => i.status === 'PENDING'));
- }
- } catch {
- router.push('/projects');
- } finally {
- setLoading(false);
- }
- }, [token, projectId, router, canManage]);
- useEffect(() => { loadAll(); }, [loadAll]);
- useEffect(() => { if (!loading && token) loadFolders(); }, [loading, token, loadFolders]);
- // ── Invite member ──────────────────────────────────────────────────────────
- const handleInvite = async (e: React.FormEvent) => {
- e.preventDefault();
- if (!token || !inviteEmail.trim()) return;
- if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(inviteEmail.trim())) {
- setInviteError('Invalid email address');
- return;
- }
- setInviting(true);
- setInviteError('');
- setInviteSuccess('');
- setCreatedLink('');
- try {
- const { inviteUrl } = await invitationsApi.create(token, projectId, inviteEmail.trim(), inviteRole);
- const { invitations } = await invitationsApi.list(token, projectId);
- setPendingInvites(invitations.filter((i: Invitation) => i.status === 'PENDING'));
- setInviteUrlMap(prev => ({ ...prev, [inviteUrl.split('/').pop()!]: inviteUrl }));
- setInviteEmail('');
- setInviteSuccess(`Invitation sent to ${inviteEmail.trim()}`);
- setTimeout(() => setInviteSuccess(''), 3000);
- } catch (err) {
- setInviteError(err instanceof Error ? err.message : 'Failed to send invitation');
- } finally {
- setInviting(false);
- }
- };
- // ── Create & copy link ─────────────────────────────────────────────────────
- const handleCreateLink = async () => {
- if (!token || !inviteEmail.trim()) return;
- setInviting(true);
- setInviteError('');
- setInviteSuccess('');
- setCreatedLink('');
- setLinkCopiedAgain(false);
- const email = inviteEmail.trim();
- try {
- const { inviteUrl } = await invitationsApi.create(token, projectId, email, inviteRole);
- const { invitations } = await invitationsApi.list(token, projectId);
- setPendingInvites(invitations.filter((i: Invitation) => i.status === 'PENDING'));
- await safeCopy(inviteUrl);
- setCreatedLink(inviteUrl);
- setCreatedLinkEmail(email);
- setInviteEmail('');
- } catch (err: any) {
- const msg = err instanceof Error ? err.message : String(err);
- if (msg.includes('already exists') || msg.includes('already a member') || msg.includes('409')) {
- setInviteError(`An invitation for "${email}" is already pending or the user is already a member.`);
- } else {
- setInviteError(msg || 'Failed to create invitation link');
- }
- } finally {
- setInviting(false);
- }
- };
- // ── Change role ────────────────────────────────────────────────────────────
- const handleChangeRole = async (memberId: string) => {
- if (!token || !editingRole) return;
- setUpdatingRole(true);
- try {
- await projectsApi.updateMember(token, projectId, memberId, editingRole);
- setMembers(prev => prev.map(m => m.id === memberId ? { ...m, role: editingRole } : m));
- setEditingRoleId(null);
- } catch (err) {
- alert(err instanceof Error ? err.message : 'Failed to update role');
- } finally {
- setUpdatingRole(false);
- }
- };
- // ── Remove member ─────────────────────────────────────────────────────────
- const handleRemoveMember = async () => {
- if (!token || !confirmRemove) return;
- setRemoving(true);
- try {
- await projectsApi.removeMember(token, projectId, confirmRemove.id);
- setMembers(prev => prev.filter(m => m.id !== confirmRemove!.id));
- setConfirmRemove(null);
- } catch (err) {
- alert(err instanceof Error ? err.message : 'Failed to remove member');
- } finally {
- setRemoving(false);
- }
- };
- // ── Revoke invite ──────────────────────────────────────────────────────────
- const handleRevoke = async (invitationId: string) => {
- if (!token) return;
- setRevokingId(invitationId);
- try {
- await invitationsApi.revoke(token, invitationId);
- setPendingInvites(prev => prev.filter(i => i.id !== invitationId));
- } catch (err) {
- alert(err instanceof Error ? err.message : 'Failed to revoke invitation');
- } finally {
- setRevokingId(null);
- }
- };
- // ── Copy invite link ──────────────────────────────────────────────────────
- const handleCopyLink = async (invite: Invitation) => {
- const base = window.location.origin;
- const url = inviteUrlMap[invite.token] ?? `${base}/invite/${invite.token}`;
- await safeCopy(url);
- setCopiedInviteId(invite.id);
- setTimeout(() => setCopiedInviteId(null), 2000);
- };
- // ── Upload ─────────────────────────────────────────────────────────────────
- const { enqueue, totalActive } = useUploadQueue();
- const handleDrop = (acceptedFiles: File[]) => {
- if (!token || acceptedFiles.length === 0) return;
- for (const file of acceptedFiles) {
- enqueue({
- projectId,
- folderId: selectedFolderId ?? undefined,
- file,
- });
- }
- };
- const { getRootProps: getUploadRootProps, getInputProps: getUploadInputProps, isDragActive: isUploadDragActive } = useDropzone({
- onDrop: handleDrop,
- accept: { 'video/*': ['.mp4', '.mov', '.webm', '.avi', '.mpeg'] },
- multiple: true,
- disabled: totalActive > 0,
- });
- // Poll for assets that are still processing
- const pollingRef = useRef<ReturnType<typeof setInterval> | null>(null);
- // ── Delete asset ─────────────────────────────────────────────────────────
- const [confirmDelete, setConfirmDelete] = useState<{ id: string; title: string } | null>(null);
- const [deletingId, setDeletingId] = useState<string | null>(null);
- const handleDeleteAsset = (id: string, title: string) => {
- setConfirmDelete({ id, title });
- };
- // ── Remove asset from a folder ──────────────────────────────────────────
- const handleRemoveFromFolder = useCallback(async (assetId: string, folderName: string) => {
- if (!token) return;
- // Find the folder by name within the project
- const folder = allFolders.find(f => f.name === folderName);
- if (!folder) return;
- try {
- await foldersApi.removeAsset(token, folder.id, assetId);
- // Refresh folder data so asset disappears from the folder
- loadFolders();
- } catch (err) {
- console.error('Failed to remove from folder:', err);
- }
- }, [token, allFolders, loadFolders]);
- const confirmDeleteAsset = async () => {
- if (!token || !confirmDelete) return;
- setDeletingId(confirmDelete.id);
- try {
- await assetsApi.delete(token, confirmDelete.id);
- setAssets(prev => prev.filter(a => a.id !== confirmDelete.id));
- setConfirmDelete(null);
- } catch (err) {
- alert(err instanceof Error ? err.message : 'Failed to delete video');
- } finally {
- setDeletingId(null);
- }
- };
- useEffect(() => {
- const processingAssets = assets.filter(a =>
- ['UPLOADING', 'PROCESSING', 'PENDING'].includes(a.transcodeStatus)
- );
- if (processingAssets.length === 0) {
- if (pollingRef.current) { clearInterval(pollingRef.current); pollingRef.current = null; }
- return;
- }
- if (pollingRef.current) return;
- pollingRef.current = setInterval(async () => {
- if (!token) return;
- try {
- const { assets: updated } = await assetsApi.list(token, projectId);
- setAssets(updated);
- } catch {}
- }, 3000);
- return () => { if (pollingRef.current) clearInterval(pollingRef.current); };
- }, [token, projectId, assets]);
- if (loading) {
- return (
- <div className="min-h-screen flex items-center justify-center" style={{ background: 'var(--bg)' }}>
- <div className="flex items-center gap-3" style={{ color: 'var(--text-muted)' }}>
- <div className="w-5 h-5 rounded-full animate-spin"
- style={{ borderColor: '#6366F1', borderTopColor: 'transparent' }} />
- <span className="text-sm">Loading…</span>
- </div>
- </div>
- );
- }
- return (
- <div className="min-h-screen" style={{ background: 'var(--bg)' }}>
- {/* Full-page upload overlay when dragging files */}
- {isUploadDragActive && (
- <div {...getUploadRootProps()} className="upload-drop-overlay">
- <input {...getUploadInputProps()} />
- <div className="text-center">
- <div className="w-16 h-16 rounded-2xl mx-auto mb-4 flex items-center justify-center"
- style={{ background: 'rgba(99,102,241,0.15)', border: '2px solid rgba(99,102,241,0.4)' }}>
- <svg className="w-8 h-8" style={{ color: '#818CF8' }} fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
- <path strokeLinecap="round" strokeLinejoin="round" d="M3 16.5v2.25A2.25 2.25 0 005.25 21h13.5A2.25 2.25 0 0021 18.75V16.5m-13.5-9L12 3m0 0l4.5 4.5M12 3v13.5" />
- </svg>
- </div>
- <p className="text-lg font-medium" style={{ color: 'var(--text)' }}>Drop videos to upload</p>
- <p className="text-sm mt-1" style={{ color: 'var(--text-muted)' }}>MP4, MOV, WebM — up to 500MB each</p>
- </div>
- </div>
- )}
- {/* Header */}
- <header className="sticky top-0 z-10 px-4 md:px-8 py-3 md:py-4 flex items-center gap-2 md:gap-4 shrink-0 flex-wrap"
- style={{
- background: 'rgba(10,11,20,0.80)',
- backdropFilter: 'blur(12px)',
- borderBottom: '1px solid rgba(255,255,255,0.06)',
- }}>
- <button
- onClick={() => router.push('/projects')}
- className="flex items-center gap-1.5 text-sm transition-colors shrink-0"
- style={{ color: 'var(--text-muted)' }}
- >
- <svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
- <path strokeLinecap="round" strokeLinejoin="round" d="M15 19l-7-7 7-7" />
- </svg>
- <span className="hidden sm:inline">Projects</span>
- </button>
- <div className="w-px h-4 hidden sm:block shrink-0" style={{ background: 'rgba(255,255,255,0.10)' }} />
- <div className="flex-1 min-w-0">
- <div className="flex items-center gap-2">
- <h1 className="text-sm font-semibold truncate" style={{ color: 'var(--text)' }}>
- {project?.name}
- </h1>
- {canManage && (
- <span className="text-[10px] px-1.5 py-0.5 rounded-full shrink-0"
- style={{ background: 'rgba(99,102,241,0.12)', color: '#A5B4FC' }}>
- {isAdmin ? 'Owner' : 'Editor'}
- </span>
- )}
- {!canManage && !isAdmin && (
- <span className="text-[10px] px-1.5 py-0.5 rounded-full shrink-0"
- style={{ background: 'rgba(255,255,255,0.04)', color: 'var(--text-subtle)' }}>
- {members.find(m => m.user.id === user?.id)?.role ?? 'Member'}
- </span>
- )}
- </div>
- {project?.description && (
- <p className="text-xs truncate mt-0.5 hidden sm:block" style={{ color: 'var(--text-muted)' }}>
- {project.description}
- </p>
- )}
- </div>
- {/* Upload button — compact, in header */}
- {canManage && (
- <button
- {...getUploadRootProps()}
- className="flex items-center gap-1.5 text-xs px-3 py-1.5 rounded-lg shrink-0 transition-all"
- style={{ background: 'rgba(99,102,241,0.12)', color: '#A5B4FC' }}
- title="Upload video"
- >
- <input {...getUploadInputProps()} />
- {totalActive > 0 ? (
- <div className="w-3.5 h-3.5 rounded-full animate-spin" style={{ borderColor: '#A5B4FC', borderTopColor: 'transparent', borderWidth: '2px' }} />
- ) : (
- <svg className="w-3.5 h-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
- <path strokeLinecap="round" strokeLinejoin="round" d="M3 16.5v2.25A2.25 2.25 0 005.25 21h13.5A2.25 2.25 0 0021 18.75V16.5m-13.5-9L12 3m0 0l4.5 4.5M12 3v13.5" />
- </svg>
- )}
- <span className="hidden sm:inline">Upload</span>
- </button>
- )}
- {/* Tabs */}
- <div className="flex items-center gap-1 p-1 rounded-lg shrink-0"
- style={{ background: 'rgba(255,255,255,0.04)' }}>
- {[
- { tab: 'videos', label: 'Videos', count: assets.length },
- { tab: 'transcode', label: 'Tasks', count: assets.filter(a => a.transcodeStatus !== 'COMPLETED').length },
- { tab: 'members', label: 'Members', count: members.length },
- ].map(({ tab, label, count }) => (
- <button key={tab}
- onClick={() => setActiveTab(tab as any)}
- className="relative px-2 sm:px-3 py-1.5 rounded-md text-xs font-medium transition-all flex items-center gap-1.5 shrink-0"
- style={{
- background: activeTab === tab ? 'rgba(99,102,241,0.20)' : 'transparent',
- color: activeTab === tab ? '#A5B4FC' : 'var(--text-muted)',
- }}
- title={label}
- >
- {tab === 'videos' && (
- <svg className="w-4 h-4 shrink-0" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
- <path strokeLinecap="round" strokeLinejoin="round" d="M15.75 10.5l4.72-4.72a.75.75 0 011.28.53v11.38a.75.75 0 01-1.28.53l-4.72-4.72M4.5 18.75h9a2.25 2.25 0 002.25-2.25v-9a2.25 2.25 0 00-2.25-2.25h-9A2.25 2.25 0 002.25 7.5v9a2.25 2.25 0 002.25 2.25z" />
- </svg>
- )}
- {tab === 'transcode' && (
- <svg className="w-4 h-4 shrink-0" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
- <path strokeLinecap="round" strokeLinejoin="round" d="M19.5 12c0-1.232-.046-2.453-.138-3.662a4.006 4.006 0 00-3.7-3.7 48.678 48.678 0 00-7.324 0 4.006 4.006 0 00-3.7 3.7c-.017.22-.032.441-.046.662M19.5 12l3-3m-3 3l-3-3m-12 3c0 1.232.046 2.453.138 3.662a4.006 4.006 0 003.7 3.7 48.656 48.656 0 007.324 0 4.006 4.006 0 003.7-3.7c.017-.22.032-.441.046-.662M4.5 12l3 3m-3-3l-3 3" />
- </svg>
- )}
- {tab === 'members' && (
- <svg className="w-4 h-4 shrink-0" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
- <path strokeLinecap="round" strokeLinejoin="round" d="M15 19.128a9.38 9.38 0 002.625.372 9.337 9.337 0 004.121-.952 4.125 4.125 0 00-7.533-2.493M15 19.128v-.003c0-1.113-.285-2.16-.786-3.07M15 19.128v.106A12.318 12.318 0 018.624 21c-2.331 0-4.512-.645-6.374-1.766l-.001-.109a6.375 6.375 0 0111.964-3.07M12 6.375a3.375 3.375 0 11-6.75 0 3.375 3.375 0 016.75 0zm8.25 2.25a2.625 2.625 0 11-5.25 0 2.625 2.625 0 015.25 0z" />
- </svg>
- )}
- <span className="hidden sm:inline">{label}</span>
- <span className="text-[10px] px-1 py-0.5 rounded-full"
- style={{
- background: tab === 'transcode'
- ? 'rgba(167,139,250,0.25)'
- : 'rgba(255,255,255,0.06)',
- color: tab === 'transcode' ? '#A78BFA' : 'inherit',
- }}>
- {count}
- </span>
- </button>
- ))}
- </div>
- {/* Delete project — owner only */}
- {isOwner && (
- <button
- onClick={() => setConfirmDeleteProject(true)}
- className="flex items-center justify-center p-1.5 rounded-lg transition-all shrink-0"
- style={{ color: '#F87171', background: 'rgba(248,113,113,0.08)' }}
- title="Delete project"
- >
- <svg className="w-3.5 h-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
- <path strokeLinecap="round" strokeLinejoin="round" d="M14.74 9l-.346 9m-4.788 0L9.26 9m9.968-3.21c.342.052.682.107 1.022.166m-1.022-.165L18.16 19.673a2.25 2.25 0 01-2.244 2.077H8.084a2.25 2.25 0 01-2.244-2.077L4.772 5.79m14.456 0a48.108 48.108 0 00-3.478-.397m-12 .562c.34-.059.68-.114 1.022-.165m0 0a48.11 48.11 0 013.478-.397m7.5 0v-.916c0-1.18-.91-2.164-2.09-2.201a51.964 51.964 0 00-3.32 0c-1.18.037-2.09 1.022-2.09 2.201v.916m7.5 0a48.667 48.667 0 00-7.5 0" />
- </svg>
- </button>
- )}
- </header>
- <div className="px-4 md:px-8 py-4 md:py-6">
- {/* ── Videos Tab ───────────────────────────────────────────────────── */}
- {activeTab === 'videos' && (
- <>
- {/* File/Timeline mode toggle + breadcrumb bar */}
- {activeTab === 'videos' && (
- <div className="flex items-center gap-3 mb-5 flex-wrap">
- {/* Breadcrumb */}
- <nav className="flex items-center gap-1 text-xs shrink min-w-0" style={{ color: 'var(--text-muted)' }}>
- <span className="truncate">{project?.name}</span>
- {breadcrumb.map((name, i) => (
- <span key={i} className="flex items-center gap-1 shrink-0">
- <svg className="w-3 h-3 shrink-0" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
- <path strokeLinecap="round" strokeLinejoin="round" d="M8.25 4.5l7.5 7.5-7.5 7.5" />
- </svg>
- <span className={i === breadcrumb.length - 1 ? '' : 'opacity-60'}>{name}</span>
- </span>
- ))}
- </nav>
- <div className="flex-1" />
- {/* Asset count */}
- <span className="text-xs px-2 py-1 rounded-full shrink-0"
- style={{ background: 'rgba(255,255,255,0.05)', color: 'var(--text-muted)' }}>
- {filteredAssets.length} video{filteredAssets.length !== 1 ? 's' : ''}
- </span>
- {/* Mode toggle */}
- <div className="flex items-center gap-0.5 p-0.5 rounded-lg shrink-0"
- style={{ background: 'rgba(255,255,255,0.05)' }}>
- {[
- { mode: 'file' as const, label: 'File', icon: (
- <svg className="w-3.5 h-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
- <path strokeLinecap="round" strokeLinejoin="round" d="M2.25 12.75V12A2.25 2.25 0 014.5 9.75h15A2.25 2.25 0 0121.75 12v.75m-8.69-6.44l-2.12-2.12a1.5 1.5 0 00-1.061-.44H4.5A2.25 2.25 0 002.25 6v12a2.25 2.25 0 002.25 2.25h15A2.25 2.25 0 0021.75 18V9a2.25 2.25 0 00-2.25-2.25h-1.5A2.25 2.25 0 0115 9v.75m-8.69-6.44H5.5a2.25 2.25 0 00-2.25 2.25v.75h13.5v-.75a2.25 2.25 0 00-2.25-2.25H12" />
- </svg>
- )},
- { mode: 'timeline' as const, label: 'Timeline', icon: (
- <svg className="w-3.5 h-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
- <path strokeLinecap="round" strokeLinejoin="round" d="M12 6v6h4.5m4.5 0a9 9 0 11-18 0 9 9 0 0118 0z" />
- </svg>
- )},
- ].map(({ mode, label, icon }) => (
- <button key={mode}
- onClick={() => setViewMode(mode)}
- className="flex items-center gap-1.5 px-2.5 py-1.5 rounded-md text-xs font-medium transition-all whitespace-nowrap"
- style={{
- background: viewMode === mode ? 'rgba(99,102,241,0.20)' : 'transparent',
- color: viewMode === mode ? '#A5B4FC' : 'var(--text-muted)',
- }}
- >
- {icon}
- <span className="hidden sm:inline">{label}</span>
- </button>
- ))}
- </div>
- </div>
- )}
- <div className="flex gap-5">
- {/* Left panel: FolderTree (both file and timeline modes) */}
- <aside className="w-52 shrink-0 hidden md:block">
- <FolderTree
- folders={folders}
- allFolders={allFolders}
- selectedFolderId={selectedFolderId}
- onSelectFolder={setSelectedFolderId}
- canManage={canManage}
- token={token ?? ''}
- projectId={projectId}
- onRefresh={loadFolders}
- totalAssetCount={assets.length}
- onFilesDropped={(files, folderId) => {
- for (const file of files) {
- enqueue({ projectId, folderId, file });
- }
- }}
- />
- </aside>
- {/* Main content */}
- <div className="flex-1 min-w-0">
- {/* Upload zone for non-managers */}
- {!canManage && (
- <div className="mb-6 rounded-2xl p-6 text-center animate-fade-in"
- style={{ background: 'rgba(255,255,255,0.01)', border: '1px solid rgba(255,255,255,0.05)', borderRadius: '16px' }}>
- <div className="w-10 h-10 rounded-2xl mx-auto mb-3 flex items-center justify-center"
- style={{ background: 'rgba(255,255,255,0.03)', border: '1px solid rgba(255,255,255,0.06)' }}>
- <svg className="w-5 h-5" style={{ color: 'var(--text-subtle)' }} fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
- <path strokeLinecap="round" strokeLinejoin="round" d="M3 16.5v2.25A2.25 2.25 0 005.25 21h13.5A2.25 2.25 0 0021 18.75V16.5m-13.5-9L12 3m0 0l4.5 4.5M12 3v13.5" />
- </svg>
- </div>
- <p className="text-xs" style={{ color: 'var(--text-muted)' }}>
- Your role ({members.find(m => m.user.id === user?.id)?.role ?? 'Member'}) does not allow uploading.
- </p>
- </div>
- )}
- {/* File mode content */}
- {viewMode === 'file' && (filteredAssets.length === 0 && subfolders.length === 0) ? (
- <div className="text-center py-16 rounded-2xl animate-fade-in"
- style={{ background: 'rgba(255,255,255,0.02)', border: '1px solid rgba(255,255,255,0.06)' }}>
- <div className="w-14 h-14 rounded-2xl mx-auto mb-4 flex items-center justify-center"
- style={{ background: 'rgba(99,102,241,0.08)', border: '1px solid rgba(99,102,241,0.12)' }}>
- <svg className="w-7 h-7" style={{ color: '#6366F1' }} fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.2}>
- <path strokeLinecap="round" strokeLinejoin="round" d="M15.75 10.5l4.72-4.72a.75.75 0 011.28.53v11.38a.75.75 0 01-1.28.53l-4.72-4.72M4.5 18.75h9a2.25 2.25 0 002.25-2.25v-9a2.25 2.25 0 00-2.25-2.25h-9A2.25 2.25 0 002.25 7.5v9a2.25 2.25 0 002.25 2.25z" />
- </svg>
- </div>
- <p className="text-sm font-medium mb-1" style={{ color: 'var(--text)' }}>
- {selectedFolderId ? 'No videos in this folder' : 'No videos yet'}
- </p>
- <p className="text-xs" style={{ color: 'var(--text-muted)' }}>
- {selectedFolderId
- ? 'Drag videos here or move them from other folders'
- : (canManage ? 'Upload your first video using the Upload button above' : 'Videos will appear here once uploaded')}
- </p>
- </div>
- ) : viewMode === 'file' ? (
- // File mode: subfolders + videos
- <div>
- {/* Subfolders */}
- {subfolders.length > 0 && (
- <div className="mb-6">
- <div className="flex items-center gap-3 mb-3">
- <span className="text-xs font-medium" style={{ color: 'var(--text-subtle)' }}>Folders</span>
- <div className="flex-1 h-px" style={{ background: 'rgba(255,255,255,0.05)' }} />
- </div>
- <div className="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 xl:grid-cols-6 gap-3">
- {subfolders.map(folder => (
- <button
- key={folder.id}
- onClick={() => setSelectedFolderId(folder.id)}
- className="flex items-center gap-2.5 px-3 py-2.5 rounded-xl text-left transition-all hover:brightness-110 group"
- style={{ background: 'rgba(255,255,255,0.03)', border: '1px solid rgba(255,255,255,0.06)' }}
- >
- <svg className="w-5 h-5 shrink-0" style={{ color: '#A78BFA' }} fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
- <path strokeLinecap="round" strokeLinejoin="round" d="M2.25 12.75V12A2.25 2.25 0 014.5 9.75h15A2.25 2.25 0 0121.75 12v.75m-8.69-6.44l-2.12-2.12a1.5 1.5 0 00-1.061-.44H4.5A2.25 2.25 0 002.25 6v12a2.25 2.25 0 002.25 2.25h15A2.25 2.25 0 0021.75 18V9A2.25 2.25 0 0019.5 6.75h-1.5A2.25 2.25 0 0115.75 9v.75m-8.69-6.44H5.5a2.25 2.25 0 00-2.25 2.25v.75h13.5v-.75a2.25 2.25 0 00-2.25-2.25H12" />
- </svg>
- <div className="flex-1 min-w-0">
- <div className="text-xs font-medium truncate" style={{ color: 'var(--text)' }}>{folder.name}</div>
- {folder.assetCount > 0 && (
- <div className="text-[10px]" style={{ color: 'var(--text-subtle)' }}>{folder.assetCount} video{folder.assetCount !== 1 ? 's' : ''}</div>
- )}
- </div>
- <svg className="w-3 h-3 shrink-0 opacity-0 group-hover:opacity-100 transition-opacity" style={{ color: 'var(--text-subtle)' }} fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
- <path strokeLinecap="round" strokeLinejoin="round" d="M8.25 4.5l7.5 7.5-7.5 7.5" />
- </svg>
- </button>
- ))}
- </div>
- </div>
- )}
- {/* Videos in this folder */}
- {filteredAssets.length > 0 && (
- <div className="grid grid-cols-1 md:grid-cols-2 xl:grid-cols-3 gap-4">
- {filteredAssets.map((asset, i) => (
- <AssetCard
- key={asset.id}
- asset={asset}
- canManage={canManage}
- showHour={false}
- onPlay={() => router.push(`/review/${asset.id}`)}
- onDelete={() => handleDeleteAsset(asset.id, asset.title)}
- onCancel={async (id) => {
- if (!token) return;
- try {
- await assetsApi.cancelTranscode(token, id);
- setAssets(prev => prev.map(a => a.id === id ? { ...a, transcodeStatus: 'PENDING', transcodeProgress: 0, transcodeError: null, hlsPath: null, transcodePaused: false } : a));
- } catch (err) { alert(err instanceof Error ? err.message : 'Failed to cancel transcode'); }
- }}
- onPause={async (id) => {
- if (!token) return;
- try { await assetsApi.pauseTranscode(token, id); setAssets(prev => prev.map(a => a.id === id ? { ...a, transcodePaused: true } : a)); }
- catch (err) { alert(err instanceof Error ? err.message : 'Failed to pause transcode'); }
- }}
- onResume={async (id) => {
- if (!token) return;
- try { await assetsApi.resumeTranscode(token, id); setAssets(prev => prev.map(a => a.id === id ? { ...a, transcodePaused: false } : a)); }
- catch (err) { alert(err instanceof Error ? err.message : 'Failed to resume transcode'); }
- }}
- animationDelay={i * 40}
- folderNames={getAssetFolderNames(assetFolders, asset.id)}
- onShare={setSharingAssetId}
- isShared={!!asset.isShared}
- onRemoveFromFolder={handleRemoveFromFolder}
- />
- ))}
- </div>
- )}
- </div>
- ) : (
- // Timeline mode: grouped by date
- <div className="space-y-8">
- {groupByDay(timelineAssets).map(([dayKey, dayAssets]) => {
- const groupDate = new Date(dayKey);
- const showHour = dayAssets.length > 1;
- return (
- <div key={dayKey}>
- <div className="flex items-center gap-3 mb-4">
- <span className="text-xs font-semibold shrink-0" style={{ color: 'var(--text-muted)' }}>
- {formatGroupDate(groupDate)}
- </span>
- <div className="flex-1 h-px" style={{ background: 'rgba(255,255,255,0.06)' }} />
- <span className="text-[10px] shrink-0" style={{ color: 'var(--text-subtle)' }}>
- {dayAssets.length} video{dayAssets.length !== 1 ? 's' : ''}
- </span>
- </div>
- <div className="space-y-3">
- {dayAssets.map((asset, i) => {
- const createdAt = new Date(asset.createdAt);
- return (
- <div key={asset.id}
- className="flex items-center gap-4 p-3 rounded-xl cursor-pointer group transition-colors animate-fade-in"
- style={{ background: 'rgba(255,255,255,0.03)', border: '1px solid rgba(255,255,255,0.05)' }}
- onClick={() => router.push(`/review/${asset.id}`)}
- draggable={canManage}
- onDragStart={canManage ? (e) => {
- e.dataTransfer.setData('assetId', asset.id);
- e.dataTransfer.setData('text/plain', asset.title);
- e.dataTransfer.effectAllowed = 'move';
- if (asset.thumbnail && asset.transcodeStatus === 'COMPLETED') {
- const ghost = document.createElement('div');
- ghost.style.cssText = 'position:fixed;top:-9999px;left:-9999px;display:flex;align-items:center;gap:8px;padding:6px 10px;background:rgba(15,15,25,0.95);border:1px solid rgba(99,102,241,0.4);border-radius:8px;backdrop-filter:blur(8px);font-family:system-ui,sans-serif;z-index:99999;';
- const img = document.createElement('img');
- img.src = `/uploads/${asset.thumbnail}`;
- img.style.cssText = 'height:48px;border-radius:5px;object-fit:cover;';
- const label = document.createElement('span');
- label.textContent = asset.title;
- label.style.cssText = 'color:#e2e8f0;font-size:12px;font-weight:500;max-width:160px;overflow:hidden;white-space:nowrap;text-overflow:ellipsis;';
- ghost.appendChild(img);
- ghost.appendChild(label);
- document.body.appendChild(ghost);
- e.dataTransfer.setDragImage(ghost, 30, 28);
- setTimeout(() => document.body.removeChild(ghost), 0);
- }
- } : undefined}
- >
- {/* Thumbnail */}
- <div className="w-24 sm:w-32 shrink-0 rounded-lg overflow-hidden aspect-video"
- style={{ background: '#080810' }}>
- {asset.thumbnail && asset.transcodeStatus === 'COMPLETED' ? (
- <img src={`/uploads/${asset.thumbnail}`} alt={asset.title} className="w-full h-full object-cover" style={{ opacity: 0.8 }} />
- ) : (
- <div className="w-full h-full flex items-center justify-center">
- <svg className="w-6 h-6" style={{ color: 'rgba(255,255,255,0.15)' }} fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1}>
- <path strokeLinecap="round" strokeLinejoin="round" d="M14.752 11.168l-3.197-2.132A1 1 0 0010 9.87v4.263a1 1 0 001.555.832l3.197-2.132a1 1 0 000-1.664z" />
- <path strokeLinecap="round" strokeLinejoin="round" d="M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
- </svg>
- </div>
- )}
- </div>
- {/* Info */}
- <div className="flex-1 min-w-0">
- <div className="flex items-start justify-between gap-2 mb-1">
- <h3 className="text-sm font-medium truncate" style={{ color: 'var(--text)' }}>{asset.title}</h3>
- {asset.duration && (
- <span className="text-xs shrink-0 px-1.5 py-0.5 rounded font-mono"
- style={{ background: 'rgba(0,0,0,0.5)', color: '#E2E8F0' }}>
- {`${Math.floor(asset.duration / 60)}:${Math.floor(asset.duration % 60).toString().padStart(2, '0')}`}
- </span>
- )}
- </div>
- {/* Folder tags */}
- {(() => {
- const tags = getAssetFolderNames(assetFolders, asset.id);
- return tags.length > 0 ? (
- <div className="flex flex-wrap gap-1 mb-1">
- {tags.map((name, i) => (
- <span key={i} className="text-[10px] px-1.5 py-0.5 rounded"
- style={{ background: 'rgba(99,102,241,0.12)', color: '#A5B4FC' }}>
- {name}
- </span>
- ))}
- </div>
- ) : null;
- })()}
- <div className="flex items-center gap-2 text-xs" style={{ color: 'var(--text-muted)' }}>
- <span className="truncate">{asset.uploader?.name ?? 'Unknown'}</span>
- <span>·</span>
- <span className="shrink-0 text-[10px]" style={{ color: 'var(--text-subtle)' }}>
- {showHour
- ? createdAt.toLocaleTimeString('en-US', { hour: '2-digit', minute: '2-digit', hour12: true })
- : createdAt.toLocaleDateString('en-US', { month: 'short', day: 'numeric' })}
- </span>
- <span>·</span>
- <span>{(asset as any)._count?.comments ?? 0} comments</span>
- </div>
- </div>
- {/* Play button */}
- <div className="w-8 h-8 rounded-full flex items-center justify-center shrink-0 opacity-0 group-hover:opacity-100 transition-opacity"
- style={{ background: 'rgba(99,102,241,0.20)', color: '#A5B4FC' }}>
- <svg className="w-4 h-4 ml-0.5" fill="currentColor" viewBox="0 0 24 24">
- <path d="M8 5v14l11-7z" />
- </svg>
- </div>
- </div>
- );
- })}
- </div>
- </div>
- );
- })}
- </div>
- )}
- </div>
- </div>
- </>
- )}
- {/* ── Transcode Tasks Tab ─────────────────────────────────────────── */}
- {activeTab === 'transcode' && (
- <div className="animate-fade-in">
- <TranscodeTasksPanel
- assets={assets}
- token={token}
- canManage={canManage}
- isAdmin={isAdmin}
- onDelete={handleDeleteAsset}
- onCancel={async (id) => {
- if (!token) return;
- try {
- await assetsApi.cancelTranscode(token, id);
- setAssets(prev => prev.map(a => a.id === id ? { ...a, transcodeStatus: 'PENDING', transcodeProgress: 0, transcodeError: null, hlsPath: null, transcodePaused: false } : a));
- } catch (err) { alert(err instanceof Error ? err.message : 'Failed to cancel transcode'); }
- }}
- onPause={async (id) => {
- if (!token) return;
- try { await assetsApi.pauseTranscode(token, id); setAssets(prev => prev.map(a => a.id === id ? { ...a, transcodePaused: true } : a)); }
- catch (err) { alert(err instanceof Error ? err.message : 'Failed to pause transcode'); }
- }}
- onResume={async (id) => {
- if (!token) return;
- try { await assetsApi.resumeTranscode(token, id); setAssets(prev => prev.map(a => a.id === id ? { ...a, transcodePaused: false } : a)); }
- catch (err) { alert(err instanceof Error ? err.message : 'Failed to resume transcode'); }
- }}
- onReprocess={async (id) => {
- if (!token) return;
- try {
- await assetsApi.cancelTranscode(token, id);
- setAssets(prev => prev.map(a => a.id === id ? { ...a, transcodeStatus: 'PENDING', transcodeProgress: 0, transcodeError: null, hlsPath: null, transcodePaused: false } : a));
- } catch (err) { alert(err instanceof Error ? err.message : 'Failed to reprocess transcode'); }
- }}
- onReprocessAll={async () => {
- if (!token) return;
- setReprocessingAll(true);
- try {
- const result = await assetsApi.reprocessAll(token, projectId as string);
- // Reset all PROCESSING assets in local state
- setAssets(prev => prev.map(a =>
- a.transcodeStatus === 'PROCESSING'
- ? { ...a, transcodeStatus: 'PENDING', transcodeProgress: 0 }
- : a
- ));
- alert(result.message);
- } catch (err) { alert(err instanceof Error ? err.message : 'Failed to reset stuck jobs'); }
- finally { setReprocessingAll(false); }
- }}
- isReprocessingAll={reprocessingAll}
- globalStuckCount={globalStuckCount}
- />
- </div>
- )}
- {/* ── Members Tab ─────────────────────────────────────────────────── */}
- {activeTab === 'members' && (
- <div className="max-w-3xl animate-fade-in">
- {/* Invite form */}
- {canManage && (
- <div className="card p-5 mb-6">
- <h2 className="text-sm font-semibold mb-4" style={{ color: 'var(--text)' }}>
- Invite someone
- </h2>
- <div className="space-y-3">
- <form
- onSubmit={e => { e.preventDefault(); handleInvite(e); }}
- className="flex items-end gap-3 flex-wrap"
- >
- <div className="flex-1 min-w-[180px]">
- <label className="block text-xs mb-1.5" style={{ color: 'var(--text-muted)' }}>
- Email address
- </label>
- <input
- type="email"
- className="input"
- value={inviteEmail}
- onChange={e => setInviteEmail(e.target.value)}
- placeholder="colleague@company.com"
- />
- </div>
- <div className="w-36">
- <label className="block text-xs mb-1.5" style={{ color: 'var(--text-muted)' }}>Role</label>
- <select
- className="input"
- value={inviteRole}
- onChange={e => setInviteRole(e.target.value)}
- >
- {Object.entries(ROLE_LABELS).map(([value, label]) => (
- <option key={value} value={value}>{label}</option>
- ))}
- </select>
- </div>
- <button
- type="button"
- disabled={inviting || !inviteEmail.trim()}
- onClick={handleCreateLink}
- className="btn btn-secondary btn-md"
- title="Create invite link and copy to clipboard"
- >
- {inviting ? 'Creating…' : (
- <span className="flex items-center gap-1.5">
- <svg className="w-3.5 h-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
- <path strokeLinecap="round" strokeLinejoin="round" d="M13.19 8.688a4.5 4.5 0 011.242 7.244l-4.5 4.5a4.5 4.5 0 01-6.364-6.364l1.757-1.757m13.35-.622l1.757-1.757a4.5 4.5 0 00-6.364-6.364l-4.5 4.5a4.5 4.5 0 001.242 7.244" />
- </svg>
- Copy Link
- </span>
- )}
- </button>
- <button
- type="submit"
- disabled={inviting || !inviteEmail.trim()}
- className="btn btn-primary btn-md"
- title="Send invite"
- >
- {inviting ? 'Sending…' : 'Send Invite'}
- </button>
- </form>
- {createdLink && (
- <div className="rounded-lg p-4 animate-scale-in"
- style={{ background: 'rgba(34,197,94,0.08)', border: '1px solid rgba(34,197,94,0.20)' }}>
- <div className="flex items-center gap-2 mb-1.5">
- <svg className="w-4 h-4 shrink-0" style={{ color: '#86EFAC' }} fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
- <path strokeLinecap="round" strokeLinejoin="round" d="M9 12.75L11.25 15 15 9.75M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
- </svg>
- <span className="text-sm font-medium" style={{ color: '#86EFAC' }}>Invitation link created!</span>
- <button
- type="button"
- onClick={async () => {
- await safeCopy(createdLink);
- setLinkCopiedAgain(true);
- setTimeout(() => setLinkCopiedAgain(false), 2000);
- }}
- className="ml-auto text-xs px-3 py-1 rounded-lg transition-all"
- style={{ background: 'rgba(255,255,255,0.06)', color: linkCopiedAgain ? '#86EFAC' : 'var(--text-muted)' }}
- >
- {linkCopiedAgain ? '✓ Copied' : 'Copy link'}
- </button>
- </div>
- <p className="text-[10px] mb-2" style={{ color: 'rgba(134,239,172,0.5)' }}>
- Invite sent to <strong style={{ color: '#86EFAC' }}>{createdLinkEmail}</strong> as {inviteRole} · Link expires in 7 days
- </p>
- <p className="text-xs break-all font-mono" style={{ color: 'rgba(134,239,172,0.7)' }}>
- {createdLink}
- </p>
- <p className="text-[10px] mt-2" style={{ color: 'rgba(134,239,172,0.45)' }}>
- Share this link with your colleague — they can use it to join the project directly.
- </p>
- </div>
- )}
- {inviteError && <p className="text-xs" style={{ color: '#F87171' }}>{inviteError}</p>}
- {inviteSuccess && <p className="text-xs" style={{ color: '#86EFAC' }}>{inviteSuccess}</p>}
- </div>
- </div>
- )}
- {/* Members list */}
- <div className="card overflow-hidden mb-6">
- <div className="px-5 py-4 border-b" style={{ borderColor: 'rgba(255,255,255,0.06)' }}>
- <h2 className="text-sm font-semibold" style={{ color: 'var(--text)' }}>
- Members ({members.length})
- </h2>
- </div>
- {members.length === 0 ? (
- <div className="p-8 text-center">
- <p className="text-sm" style={{ color: 'var(--text-muted)' }}>No members yet</p>
- </div>
- ) : (
- <div className="divide-y" style={{ borderColor: 'rgba(255,255,255,0.04)' }}>
- {members.map(m => {
- const isMe = m.user.id === user?.id;
- const canEdit = isAdmin && !isMe;
- return (
- <div key={m.id}
- className="flex items-center gap-4 px-5 py-4 hover:bg-white/[0.02] transition-colors">
- <Avatar name={m.user.name} src={m.user.avatarUrl} size="md" />
- <div className="flex-1 min-w-0">
- <div className="flex items-center gap-2">
- <span className="text-sm font-medium" style={{ color: 'var(--text)' }}>
- {m.user.name}
- {isMe && <span className="ml-1.5 text-[10px]" style={{ color: 'var(--text-subtle)' }}>(you)</span>}
- </span>
- </div>
- <p className="text-xs truncate" style={{ color: 'var(--text-muted)' }}>{m.user.email}</p>
- </div>
- <span className="text-xs hidden sm:block" style={{ color: 'var(--text-subtle)' }}>
- {new Date(m.joinedAt).toLocaleDateString('en-US', { month: 'short', year: 'numeric' })}
- </span>
- {editingRoleId === m.id ? (
- <div className="flex items-center gap-2 shrink-0">
- <select
- className="input text-xs py-1.5"
- value={editingRole}
- onChange={e => setEditingRole(e.target.value)}
- autoFocus
- >
- {Object.entries(ROLE_LABELS).map(([v, l]) => (
- <option key={v} value={v}>{l}</option>
- ))}
- </select>
- <button onClick={() => handleChangeRole(m.id)} disabled={updatingRole} className="btn btn-primary btn-sm px-2" title="Save">
- <svg className="w-3.5 h-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
- <path strokeLinecap="round" strokeLinejoin="round" d="M5 13l4 4L19 7" />
- </svg>
- </button>
- <button onClick={() => setEditingRoleId(null)} className="btn btn-secondary btn-sm px-2" title="Cancel">
- <svg className="w-3.5 h-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
- <path strokeLinecap="round" strokeLinejoin="round" d="M6 18L18 6M6 6l12 12" />
- </svg>
- </button>
- </div>
- ) : (
- <div className="flex items-center gap-2 shrink-0">
- <span className={`badge ${ROLE_COLORS[m.role] ?? 'badge-muted'}`}>
- {ROLE_LABELS[m.role] ?? m.role}
- </span>
- {canEdit && (
- <button
- onClick={() => { setEditingRoleId(m.id); setEditingRole(m.role); }}
- className="btn btn-secondary btn-sm"
- title="Change role"
- >
- <svg className="w-3.5 h-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
- <path strokeLinecap="round" strokeLinejoin="round" d="M16.862 4.487l1.687-1.688a1.875 1.875 0 112.652 2.652L10.582 16.07a4.5 4.5 0 01-1.897 1.13L6 18l.8-2.685a4.5 4.5 0 011.13-1.897l8.932-8.931zm0 0L19.5 7.125" />
- </svg>
- </button>
- )}
- {isAdmin && !isMe && (
- <button
- onClick={() => setConfirmRemove({ id: m.user.id, name: m.user.name })}
- className="btn btn-danger btn-sm"
- title="Remove from project"
- >
- <svg className="w-3.5 h-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
- <path strokeLinecap="round" strokeLinejoin="round" d="M22 12h-4l-3 9L9 3l-3 9H2" />
- </svg>
- </button>
- )}
- </div>
- )}
- </div>
- );
- })}
- </div>
- )}
- </div>
- {/* Pending invitations */}
- {canManage && (
- <div className="card overflow-hidden">
- <div className="px-5 py-4 border-b flex items-center justify-between" style={{ borderColor: 'rgba(255,255,255,0.06)' }}>
- <h2 className="text-sm font-semibold" style={{ color: 'var(--text)' }}>
- Pending invitations
- </h2>
- <span className="text-xs px-2 py-0.5 rounded-full"
- style={{ background: 'rgba(255,255,255,0.05)', color: 'var(--text-muted)' }}>
- {pendingInvites.length}
- </span>
- </div>
- {pendingInvites.length === 0 ? (
- <div className="p-8 text-center">
- <p className="text-xs" style={{ color: 'var(--text-subtle)' }}>No pending invitations</p>
- </div>
- ) : (
- <div className="divide-y" style={{ borderColor: 'rgba(255,255,255,0.04)' }}>
- {pendingInvites.map(inv => (
- <div key={inv.id}
- className="flex items-center gap-4 px-5 py-4">
- <div className="w-9 h-9 rounded-full flex items-center justify-center shrink-0"
- style={{ background: 'rgba(99,102,241,0.08)' }}>
- <svg className="w-4 h-4" style={{ color: '#818CF8' }} fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
- <path strokeLinecap="round" strokeLinejoin="round" d="M21.75 6.75v10.5a2.25 2.25 0 01-2.25 2.25h-15a2.25 2.25 0 01-2.25-2.25V6.75m19.5 0A2.25 2.25 0 0019.5 4.5h-15a2.25 2.25 0 00-2.25 2.25m19.5 0v.243a2.25 2.25 0 01-1.07 1.916l-7.5 4.615a2.25 2.25 0 01-2.36 0L3.32 8.91a2.25 2.25 0 01-1.07-1.916V6.75" />
- </svg>
- </div>
- <div className="flex-1 min-w-0">
- <div className="flex items-center gap-2">
- <span className="text-sm font-medium" style={{ color: 'var(--text)' }}>{inv.email}</span>
- <span className={`badge ${ROLE_COLORS[inv.role] ?? 'badge-muted'}`}>
- {ROLE_LABELS[inv.role] ?? inv.role}
- </span>
- </div>
- <div className="flex items-center gap-3 mt-0.5 text-xs" style={{ color: 'var(--text-subtle)' }}>
- <span>Sent {new Date(inv.createdAt).toLocaleDateString()}</span>
- <span>·</span>
- <span>Expires {new Date(inv.expiresAt).toLocaleDateString()}</span>
- </div>
- </div>
- <div className="flex items-center gap-1.5 shrink-0">
- <button
- onClick={() => handleCopyLink(inv)}
- className="btn btn-secondary btn-sm"
- title="Copy invite link"
- >
- {copiedInviteId === inv.id ? (
- <svg className="w-3.5 h-3.5" style={{ color: '#86EFAC' }} fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
- <path strokeLinecap="round" strokeLinejoin="round" d="M5 13l4 4L19 7" />
- </svg>
- ) : (
- <svg className="w-3.5 h-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
- <path strokeLinecap="round" strokeLinejoin="round" d="M13.19 8.688a4.5 4.5 0 011.242 7.244l-4.5 4.5a4.5 4.5 0 01-6.364-6.364l1.757-1.757m13.35-.622l1.757-1.757a4.5 4.5 0 00-6.364-6.364l-4.5 4.5a4.5 4.5 0 001.242 7.244" />
- </svg>
- )}
- </button>
- <button
- onClick={() => handleRevoke(inv.id)}
- disabled={revokingId === inv.id}
- className="btn btn-danger btn-sm"
- title="Revoke invitation"
- >
- {revokingId === inv.id ? '…' : (
- <svg className="w-3.5 h-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
- <path strokeLinecap="round" strokeLinejoin="round" d="M6 18L18 6M6 6l12 12" />
- </svg>
- )}
- </button>
- </div>
- </div>
- ))}
- </div>
- )}
- {pendingInvites.length > 0 && (
- <div className="px-5 py-3 border-t" style={{ borderColor: 'rgba(255,255,255,0.04)' }}>
- <p className="text-xs" style={{ color: 'var(--text-subtle)' }}>
- Invitation links expire after 7 days. Copy the link and send it manually, or ask the recipient to check their email.
- </p>
- </div>
- )}
- </div>
- )}
- </div>
- )}
- </div>
- {/* Share modal */}
- {sharingAssetId && (
- <ShareModal
- assetId={sharingAssetId}
- onClose={() => setSharingAssetId(null)}
- />
- )}
- {/* Delete asset confirm modal */}
- {confirmDelete && (
- <div className="fixed inset-0 z-50 flex items-center justify-center"
- style={{ background: 'rgba(0,0,0,0.7)' }}>
- <div className="card p-6 max-w-sm w-full mx-4 animate-scale-in">
- <div className="flex items-center gap-3 mb-4">
- <div className="w-10 h-10 rounded-full flex items-center justify-center shrink-0"
- style={{ background: 'rgba(248,113,113,0.15)' }}>
- <svg className="w-5 h-5" style={{ color: '#F87171' }} fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
- <path strokeLinecap="round" strokeLinejoin="round" d="M14.74 9l-.346 9m-4.788 0L9.26 9m9.968-3.21c.342.052.682.107 1.022.166m-1.022-.165L18.16 19.673a2.25 2.25 0 01-2.244 2.077H8.084a2.25 2.25 0 01-2.244-2.077L4.772 5.79m14.456 0a48.108 48.108 0 00-3.478-.397m-12 .562c.34-.059.68-.114 1.022-.165m0 0a48.11 48.11 0 013.478-.397m7.5 0v-.916c0-1.18-.91-2.164-2.09-2.201a51.964 51.964 0 00-3.32 0c-1.18.037-2.09 1.022-2.09 2.201v.916m7.5 0a48.667 48.667 0 00-7.5 0" />
- </svg>
- </div>
- <div>
- <h3 className="text-base font-semibold" style={{ color: 'var(--text)' }}>Delete video?</h3>
- <p className="text-xs mt-0.5 truncate max-w-[220px]" style={{ color: 'var(--text-muted)' }}>
- "{confirmDelete.title}"
- </p>
- </div>
- </div>
- <p className="text-sm mb-5" style={{ color: 'var(--text-muted)' }}>
- This will permanently delete the video file, thumbnail, and all HLS segments. This action cannot be undone.
- </p>
- <div className="flex gap-3 justify-end">
- <button onClick={() => setConfirmDelete(null)} disabled={!!deletingId} className="btn btn-secondary btn-md">Cancel</button>
- <button onClick={confirmDeleteAsset} disabled={!!deletingId} className="btn btn-danger btn-md">
- {deletingId === confirmDelete.id ? 'Deleting…' : 'Delete video'}
- </button>
- </div>
- </div>
- </div>
- )}
- {/* Remove member confirm modal */}
- {confirmRemove && (
- <div className="fixed inset-0 z-50 flex items-center justify-center"
- style={{ background: 'rgba(0,0,0,0.7)' }}>
- <div className="card p-6 max-w-sm w-full mx-4 animate-scale-in">
- <h3 className="text-base font-semibold mb-2" style={{ color: 'var(--text)' }}>
- Remove {confirmRemove.name}?
- </h3>
- <p className="text-sm mb-5" style={{ color: 'var(--text-muted)' }}>
- They'll lose access to this project and all its videos. They can rejoin if invited again.
- </p>
- <div className="flex gap-3 justify-end">
- <button onClick={() => setConfirmRemove(null)} className="btn btn-secondary btn-md">Cancel</button>
- <button onClick={handleRemoveMember} disabled={removing} className="btn btn-danger btn-md">
- {removing ? 'Removing…' : 'Remove'}
- </button>
- </div>
- </div>
- </div>
- )}
- {/* Delete project confirm modal */}
- {confirmDeleteProject && (
- <div className="fixed inset-0 z-50 flex items-center justify-center"
- style={{ background: 'rgba(0,0,0,0.7)' }}>
- <div className="card p-6 max-w-sm w-full mx-4 animate-scale-in">
- <div className="flex items-center gap-3 mb-4">
- <div className="w-10 h-10 rounded-full flex items-center justify-center shrink-0"
- style={{ background: 'rgba(248,113,113,0.15)' }}>
- <svg className="w-5 h-5" style={{ color: '#F87171' }} fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
- <path strokeLinecap="round" strokeLinejoin="round" d="M14.74 9l-.346 9m-4.788 0L9.26 9m9.968-3.21c.342.052.682.107 1.022.166m-1.022-.165L18.16 19.673a2.25 2.25 0 01-2.244 2.077H8.084a2.25 2.25 0 01-2.244-2.077L4.772 5.79m14.456 0a48.108 48.108 0 00-3.478-.397m-12 .562c.34-.059.68-.114 1.022-.165m0 0a48.11 48.11 0 013.478-.397m7.5 0v-.916c0-1.18-.91-2.164-2.09-2.201a51.964 51.964 0 00-3.32 0c-1.18.037-2.09 1.022-2.09 2.201v.916m7.5 0a48.667 48.667 0 00-7.5 0" />
- </svg>
- </div>
- <div>
- <h3 className="text-base font-semibold" style={{ color: 'var(--text)' }}>
- Delete "{project?.name}"?
- </h3>
- <p className="text-xs mt-0.5" style={{ color: '#F87171' }}>
- {assets.length} video{assets.length !== 1 ? 's' : ''} will be permanently deleted
- </p>
- </div>
- </div>
- <p className="text-sm mb-5" style={{ color: 'var(--text-muted)' }}>
- This will permanently delete the project, all videos, comments, and assets. This action cannot be undone.
- </p>
- <div className="flex gap-3 justify-end">
- <button
- onClick={() => setConfirmDeleteProject(false)}
- disabled={deletingProject}
- className="btn btn-secondary btn-md"
- >
- Cancel
- </button>
- <button
- onClick={handleDeleteProject}
- disabled={deletingProject}
- className="btn btn-danger btn-md"
- >
- {deletingProject ? 'Deleting…' : 'Delete Project'}
- </button>
- </div>
- </div>
- </div>
- )}
- </div>
- );
- }
|